Skip to content

[minor] MLAI-1288 - Enforce Cursor skill governance through agent-guard - #81

Open
shmuelqwak wants to merge 4 commits into
mainfrom
feature/MLAI-1288-cursor-governance-via-agent-guard
Open

[minor] MLAI-1288 - Enforce Cursor skill governance through agent-guard#81
shmuelqwak wants to merge 4 commits into
mainfrom
feature/MLAI-1288-cursor-governance-via-agent-guard

Conversation

@shmuelqwak

Copy link
Copy Markdown
Collaborator

Moves Cursor skill governance out of in-plugin Node and into agent-guard, and brings the Cursor wiring in line with the Claude Code plugin — where the behaviour genuinely should match, and deliberately not where Cursor differs.

What the hook does now

npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 \
JF_AGENT_GUARD_ENFORCE_DEADLINE="$(($(date +%s) + 25))" \
npx --yes --prefer-offline --registry "${JFROG_AGENT_GUARD_REPO:-…}" \
  @jfrog/agent-guard --enforce-skill --client cursor

Byte-identical on both governed surfaces — beforeSubmitPrompt (unmatched, so every submission) and preToolUse (matcher Read, since Cursor has no Skill tool).

before after
|| exit 2 present removed
--waiver-helper passed removed — gone from agent-guard's flag table
version ${JFROG_AGENT_GUARD_VERSION:+@…} unpinned → always latest
deadline none absolute, computed fresh, no inheritable fallback
fetch bounds none retries=0, timeout=10000
cache none --prefer-offline, refreshed by a sessionStart pre-warm
timeout / failClosed 20 / true 30 / false

Infrastructure failure now fails OPEN

|| exit 2 is gone and failClosed is false. npx missing, a failed install, an unreachable registry, or no configured JFrog server all let the skill through. A machine that cannot get a verdict is not governed by it, and refusing every skill there enforces nothing except the user's inability to work.

A real policy denial still blocks: on Cursor the verdict travels as JSON on stdout with exit 0, so the payload carries it regardless of failClosed. failClosed was only ever a crash net, never how a block is signalled.

This is where Cursor deliberately diverges from Claude Code. The Claude plugin blocks when the guard reaches the check but cannot finish it in time; here that case allows, because a killed or failed hook is not a block under failClosed: false. The README states the divergence rather than papering over it.

Verified against a genuinely unconfigured machine (0 jf servers, no setup.json, no env):

beforeSubmitPrompt -> {"continue":true}   exit=0
preToolUse         -> {}                  exit=0
agent-guard: could not resolve JFrog credentials, so this skill is NOT governed

Both are the correct Cursor allow shapes — explicit JSON, never silence.

The waiver helper is deleted

agent-guard v1.11.0 files waivers itself via --request-waiver, and --waiver-helper is no longer in its flag table. plugins/jfrog/scripts/governance/ is removed entirely, so the plugin holds no credentials and no waiver logic.

Two earlier commits on this branch had rewritten request-waiver.mjs to the Unified Policy Phase II payload. That work is obsolete under the new split, so the branch was recommitted without it.

Nothing may pin the version

All three invocations name @jfrog/agent-guard bare. Proven empirically, not just asserted — with JFROG_AGENT_GUARD_VERSION=9.9.9 exported, argv still carries bare @jfrog/agent-guard. A shipped GA fix therefore reaches users without a plugin release.

Minimum required: agent-guard ≥ 1.11.0. This matters more than it looks. On 1.10.0 the same event blocks:

no project, no credentials
v1.10.0 {"continue":false} — "no project is set"
v1.11.0 {"continue":true} — allowed

1.10.0 decided "no project" locally; 1.11.0 refers it to the service and fails open on unresolvable credentials.

Why sessionStart pre-warms the cache

--prefer-offline on the governed hooks is only safe if something refreshes the npx cache. Without it, a machine can serve a stale agent-guard indefinitely — this was hit in real testing: a cached 1.10.0 kept being used while 1.11.0 was latest, silently reinstating the bug above. It read as a governance verdict, not as a stale binary.

Cursor's hook schema has no async field, so the pre-warm detaches itself:

( npx --yes --registry "${JFROG_AGENT_GUARD_REPO:-…}" @jfrog/agent-guard --version >/dev/null 2>&1 & ) ; exit 0

Measured: returns in 0s, and the detached fetch lands the latest version in the cache. It deliberately omits --prefer-offline, since hitting the registry is its only purpose, and the explicit exit 0 keeps a warm failure from ever failing session start.

Validation

scripts/validate-skill-governance.mjs was rewritten — its old assertions demanded the exact things this PR removes (must end in "|| exit 2", must pass the waiver helper, failClosed === true). 34 checks, all passing, asserting the inverted contract and executing the real command string from hooks.json against a stub npx.

Behavioural checks run against both surfaces with surface-shaped payloads, rather than testing preToolUse and inheriting the result. Notable checks:

  • npm_config_fetch_timeout is asserted by value (<= 10000), so an edit back to npm's 300000 default fails rather than passing a presence check.
  • The deadline check executes the command and reads what the child receives, including that a stale inherited JF_AGENT_GUARD_ENFORCE_DEADLINE does not survive.
  • The pre-warm must exist, must not use --prefer-offline, must not pin a version, must detach, and must end in exit 0.
  • The package-resolution sessionStart hook is located by content, never by index — that list is shared and other features append to it; it has already moved once.

Not verified

  • Windows. Cursor's hook schema has no shell field, so the command runs in the platform's default shell. It is POSIX (${VAR:-default}, $(( ))), so on cmd/PowerShell it cannot run and governed actions are allowed unchecked. Stated in the README; unchanged from before this PR, which was equally POSIX-only.
  • Warm per-call cost. The hook spawns a shell, npx and the guard on every prompt submission and every Read, with no throttle and no verdict cache. --prefer-offline is what makes that affordable, but the warm cost has not been measured.
  • Cold start allows. With no pre-warm completed and failClosed: false, the first governed action on a cold machine can be allowed. The detached pre-warm narrows the window but does not close it.

Release

[minor]0.5.180.6.0, bumped in both plugins/jfrog/.cursor-plugin/plugin.json and .cursor-plugin/marketplace.json as validate-version.yml requires.

shmuelqwak and others added 3 commits August 27, 2026 14:54
Replaces the in-plugin Node governance with a direct npx call to
agent-guard, and brings the Cursor wiring in line with the Claude Code
plugin.

Infrastructure failure now fails OPEN. `|| exit 2` is gone and
`failClosed` is false: npx missing, a failed install, an unreachable
registry or no configured server let the skill through. A machine that
cannot run the guard is not governed by it, and refusing every skill
there enforces nothing except the user's inability to work. A real
policy denial still blocks — on Cursor the verdict travels as JSON with
exit 0, so the payload carries it regardless of failClosed.

The waiver helper is deleted. agent-guard v1.11.0 files waivers itself
via --request-waiver, and --waiver-helper is no longer in its flag
table, so the plugin holds no credentials and no waiver logic.

Nothing may pin the version: the hook resolves `latest` so a shipped GA
fix reaches users without a plugin release. The call also bounds its
fetch, prefers the cache, and passes an absolute deadline computed
fresh at invocation — with no inheritable fallback, since an absolute
instant inherited from an earlier process pins every later invocation
to the past.

No sessionStart pre-warm: Cursor's hook schema has no async field, so
one would stall session start. Cold start therefore allows; tracked as
a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sor-governance-via-agent-guard

# Conflicts:
#	.cursor-plugin/marketplace.json
#	README.md
#	plugins/jfrog/.cursor-plugin/plugin.json
--prefer-offline on the governed hooks is only safe if something refreshes
the npx cache. Claude Code's plugin does that with an async SessionStart
pre-warm; dropping it here left a machine able to serve a stale binary
indefinitely.

Measured: a cached 1.10.0 kept being used while 1.11.0 was latest, which
silently reinstated a bug 1.11.0 had fixed — 1.10.0 decides "no project"
locally and BLOCKS, where 1.11.0 refers it to the service and fails open
on unresolvable credentials. The stale cache read as a governance verdict.

Cursor's hook schema has no async field, so the command detaches itself:
a subshell background plus an explicit exit 0, which returns in 0s and
never fails session start. It deliberately omits --prefer-offline, since
hitting the registry is its only purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shmuelqwak
shmuelqwak requested a review from a team as a code owner August 27, 2026 15:01
An absent date(1) made $(($(date +%s) + 25)) expand to 25 — an epoch in
1970 — which floors the enforcement budget at 500ms and blocks every
skill, reported as a governance timeout. Passing empty instead is safe:
agent-guard ignores an empty deadline and uses its own default budget.

failClosed: false does not cover this. The block arrives as a deny in
the JSON payload, which agent-guard produced deliberately; failClosed
only governs a hook that fails on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@YoniMelki YoniMelki left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comments list the required changes.

Comment thread README.md
**Requirements & behavior**

> [!IMPORTANT]
> **On Cursor, only an answer can block.** A verdict reaches Cursor as JSON on the hook's stdout;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text says that only JSON on stdout can block and that the hook always exits 0. Cursor treats exit code 2 as a deny. failClosed: false does not change that. scripts/validate-skill-governance.mjs already asserts that path. An Agent Guard internal deadline that exits 2 will block. A Cursor hook kill after the timeout will allow. Split those two cases in this note.

name: Validate skill governance

on:
pull_request:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs only on a pull request. .github/workflows/release.yml does not run scripts/validate-skill-governance.mjs. CONTRIBUTING.md states that pull-request checks do not see the merge commit. Add the same command to the release job.

Comment thread README.md
> there. A user entitled to nothing is unaffected either way: the Agent Guard answers "allow" for an
> unconfigured or unentitled user, so no setup is needed to opt out of the feature.
>
> A user who is entitled to nothing is unaffected either way: the Agent Guard returns "allow" for

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this paragraph. The sentence above it already states the same rule.

"preToolUse": [
{
"command": "_JFAG_NOW=$(date +%s 2>/dev/null); npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"${_JFAG_NOW:+$((_JFAG_NOW + 25))}\" npx --yes --prefer-offline --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor",
"matcher": "Read",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preToolUse matches only Read. Other tools can put a skill body in front of the model. The Skills governance section already states that gap. Align the capabilities table with that limit.

assert(blocks(r), "a deny payload must read as a block");
});

await check(`${step}: an allow is forwarded as-is (Cursor requires explicit JSON, not silence)`, async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks use a stub npx. They do not run a real @jfrog/agent-guard binary. They also omit JSON that is not valid and empty stdout with exit 0.

branches: [main]
paths:
# Governance lives entirely in agent-guard now, so the wiring IS the hooks file. The
# scripts/** glob stays so that re-adding any plugin-side governance code re-triggers the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path list watches plugins/jfrog/scripts/**. It does not watch plugins/jfrog/modules/**. The no-governance check uses a fixed list under scripts/governance/. Include plugins/jfrog/modules/** in the path list.

"timeout": 60
},
{
"command": "( npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --version >/dev/null 2>&1 & ) ; exit 0",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command returns at once with exit 0. If Cursor stops the hook process group, the background npx does not refresh the cache. The validator only checks the command shape. Confirm that the detached process stays alive after the hook returns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants